mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
Connect: Improve search bar readability (#65781)
* Improve results layout * Do not show matches in resource's secondary properties/labels if there's already a match on the main field * Adjust stories and add tests * Change `font-weight` to 400 for better redability * Add tests for `getVisibleMatches` * Remove unnecessary trailing spaces
This commit is contained in:
@@ -23,8 +23,10 @@ import { makeSuccessAttempt } from 'shared/hooks/useAsync';
|
||||
|
||||
import Logger, { NullService } from 'teleterm/logger';
|
||||
import {
|
||||
makeLabelsList,
|
||||
makeRetryableError,
|
||||
makeRootCluster,
|
||||
makeServer,
|
||||
} from 'teleterm/services/tshd/testHelpers';
|
||||
import { AppUpdaterContextProvider } from 'teleterm/ui/AppUpdater';
|
||||
import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
|
||||
@@ -389,6 +391,45 @@ it('closes on a click on an unfocusable element outside of the search bar', asyn
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows secondary label matches for terms not already matched by the main field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const appContext = setUpContext('/clusters/foo');
|
||||
const resourceSearchResult = {
|
||||
kind: 'server' as const,
|
||||
requiresRequest: false,
|
||||
resource: makeServer({
|
||||
hostname: 'ansible',
|
||||
addr: 'dev-host.internal:3022',
|
||||
labels: makeLabelsList({ owner: 'an', env: 'dev', creator: 'admin' }),
|
||||
}),
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(appContext.resourcesService, 'searchResources')
|
||||
.mockResolvedValue([resourceSearchResult]);
|
||||
|
||||
render(
|
||||
<MockAppContextProvider appContext={appContext}>
|
||||
<ConnectionsContextProvider>
|
||||
<VnetContextProvider>
|
||||
<SearchBarConnected />
|
||||
</VnetContextProvider>
|
||||
</ConnectionsContextProvider>
|
||||
</MockAppContextProvider>
|
||||
);
|
||||
|
||||
const searchbox = await screen.findByRole('searchbox');
|
||||
await user.type(searchbox, 'ab dev');
|
||||
|
||||
expect(await screen.findByText('Connect over SSH')).toBeVisible();
|
||||
const results = await screen.findByRole('menu');
|
||||
expect(results).toHaveTextContent('env: dev');
|
||||
// The result was already matched by the main field ("ansible").
|
||||
expect(results).not.toHaveTextContent('owner: an');
|
||||
// This label is omitted because it has no matches.
|
||||
expect(results).not.toHaveTextContent('creator: admin');
|
||||
});
|
||||
|
||||
const getMockedSearchContext = (): SearchContext.SearchContext => ({
|
||||
inputValue: 'foo',
|
||||
filters: [],
|
||||
|
||||
@@ -156,6 +156,9 @@ function SearchBar() {
|
||||
height: 100%;
|
||||
border: 1px ${props => props.theme.colors.buttons.border.border} solid;
|
||||
border-radius: ${props => props.theme.radii[2]}px;
|
||||
* {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${props => props.theme.colors.spotBackground[0]};
|
||||
|
||||
@@ -19,12 +19,15 @@
|
||||
import { makeSuccessAttempt } from 'shared/hooks/useAsync';
|
||||
|
||||
import {
|
||||
makeLabelsList,
|
||||
makeRetryableError,
|
||||
makeRootCluster,
|
||||
makeServer,
|
||||
} from 'teleterm/services/tshd/testHelpers';
|
||||
import { makeResourceResult } from 'teleterm/ui/Search/testHelpers';
|
||||
import { ResourceSearchError } from 'teleterm/ui/services/resources';
|
||||
|
||||
import { getActionPickerStatus } from './ActionPicker';
|
||||
import { getActionPickerStatus, getVisibleMatches } from './ActionPicker';
|
||||
|
||||
describe('getActionPickerStatus', () => {
|
||||
describe('some-input search mode', () => {
|
||||
@@ -203,3 +206,100 @@ describe('getActionPickerStatus', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleMatches', () => {
|
||||
it('excludes secondary field and label matches for terms already matched in the main field', () => {
|
||||
const searchResult = makeResourceResult({
|
||||
kind: 'server',
|
||||
resource: makeServer({
|
||||
labels: makeLabelsList({ env: 'teleport', team: 'platform' }),
|
||||
}),
|
||||
resourceMatches: [
|
||||
{ field: 'hostname', searchTerm: 'teleport' },
|
||||
{ field: 'addr', searchTerm: 'teleport' },
|
||||
{ field: 'name', searchTerm: 'platform' },
|
||||
],
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'env',
|
||||
searchTerm: 'teleport',
|
||||
score: 2,
|
||||
},
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'team',
|
||||
searchTerm: 'platform',
|
||||
score: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const visibleMatches = getVisibleMatches(searchResult, 'hostname');
|
||||
|
||||
// Not visible because `addr` only matched a term already matched in `hostname` (main field).
|
||||
expect(visibleMatches.hasMatchOnField('addr')).toBe(false);
|
||||
// Visible because `name` matched a different term than the one matched in `hostname`.
|
||||
expect(visibleMatches.hasMatchOnField('name')).toBe(true);
|
||||
// Only non-main-field term label matches remain.
|
||||
expect(visibleMatches.labels.matches).toEqual([
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'team',
|
||||
searchTerm: 'platform',
|
||||
score: 3,
|
||||
},
|
||||
]);
|
||||
expect(visibleMatches.labels.list).toEqual([
|
||||
{ name: 'team', value: 'platform' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts visible labels by accumulated score in descending order', () => {
|
||||
const searchResult = makeResourceResult({
|
||||
kind: 'server',
|
||||
resource: makeServer({
|
||||
labels: makeLabelsList({
|
||||
low: 'one',
|
||||
high: 'two',
|
||||
medium: 'three',
|
||||
}),
|
||||
}),
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'medium',
|
||||
searchTerm: 'm1',
|
||||
score: 2,
|
||||
},
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'high',
|
||||
searchTerm: 'h1',
|
||||
score: 3,
|
||||
},
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'high',
|
||||
searchTerm: 'h2',
|
||||
score: 2,
|
||||
},
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'low',
|
||||
searchTerm: 'l1',
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const visibleMatches = getVisibleMatches(searchResult, 'hostname');
|
||||
|
||||
expect(visibleMatches.labels.list.map(label => label.name)).toEqual([
|
||||
'high',
|
||||
'medium',
|
||||
'low',
|
||||
]);
|
||||
expect(visibleMatches.labels.matches).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,14 +16,17 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { ReactElement, useCallback, useMemo } from 'react';
|
||||
import React, { ReactElement, ReactNode, useCallback, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, ButtonBorder, Label as DesignLabel, Flex, Text } from 'design';
|
||||
import { Box, ButtonBorder, Flex, Label as DesignLabel, Text } from 'design';
|
||||
import { makeLabelTag } from 'design/formatters';
|
||||
import * as icons from 'design/Icon';
|
||||
import { Cross as CloseIcon } from 'design/Icon';
|
||||
import { IconProps } from 'design/Icon/Icon';
|
||||
import { App } from 'gen-proto-ts/teleport/lib/teleterm/v1/app_pb';
|
||||
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
|
||||
import { Label as LabelProto } from 'gen-proto-ts/teleport/lib/teleterm/v1/label_pb';
|
||||
import { AdvancedSearchToggle } from 'shared/components/AdvancedSearchToggle';
|
||||
import { Highlight } from 'shared/components/Highlight';
|
||||
import {
|
||||
@@ -33,11 +36,11 @@ import {
|
||||
} from 'shared/hooks/useAsync';
|
||||
|
||||
import { isWebApp } from 'teleterm/services/tshd/app';
|
||||
import * as tsh from 'teleterm/services/tshd/types';
|
||||
import { useAppContext } from 'teleterm/ui/appContextProvider';
|
||||
import {
|
||||
DisplayResults,
|
||||
isClusterSearchFilter,
|
||||
LabelMatch,
|
||||
ResourceMatch,
|
||||
ResourceSearchResult,
|
||||
SearchFilter,
|
||||
@@ -425,7 +428,7 @@ export function getActionPickerStatus({
|
||||
inputValue: string;
|
||||
filters: SearchFilter[];
|
||||
filterActions: SearchAction[];
|
||||
allClusters: tsh.Cluster[];
|
||||
allClusters: Cluster[];
|
||||
resourceSearchAttempt: Attempt<CrossClusterResourceSearchResult>;
|
||||
}): ActionPickerStatus {
|
||||
if (!inputValue) {
|
||||
@@ -539,12 +542,10 @@ function ClusterFilterItem(props: SearchResultItem<SearchResultCluster>) {
|
||||
<IconAndContent Icon={icons.Lan} iconColor="text.slightlyMuted">
|
||||
<Text typography="body2">
|
||||
Search only in{' '}
|
||||
<strong>
|
||||
<Highlight
|
||||
text={props.searchResult.resource.name}
|
||||
keywords={[props.searchResult.nameMatch]}
|
||||
/>
|
||||
</strong>
|
||||
<Highlight
|
||||
text={props.searchResult.resource.name}
|
||||
keywords={[props.searchResult.nameMatch]}
|
||||
/>
|
||||
</Text>
|
||||
</IconAndContent>
|
||||
);
|
||||
@@ -564,12 +565,10 @@ function DisplayResultsItem(props: SearchResultItem<DisplayResults>) {
|
||||
{props.searchResult.value && (
|
||||
<>
|
||||
for{' '}
|
||||
<strong>
|
||||
<Highlight
|
||||
keywords={[props.searchResult.value]}
|
||||
text={props.searchResult.value}
|
||||
/>
|
||||
</strong>
|
||||
<Highlight
|
||||
keywords={[props.searchResult.value]}
|
||||
text={props.searchResult.value}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{props.searchResult.documentUri
|
||||
@@ -577,7 +576,7 @@ function DisplayResultsItem(props: SearchResultItem<DisplayResults>) {
|
||||
: ' in a new tab'}
|
||||
</Text>
|
||||
<Box ml="auto">
|
||||
<Text typography="body4">
|
||||
<Text typography="body4" color="text.muted">
|
||||
{props.getOptionalClusterName(props.searchResult.clusterUri)}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -611,12 +610,10 @@ function ResourceTypeFilterItem(
|
||||
>
|
||||
<Text typography="body2">
|
||||
Search for{' '}
|
||||
<strong>
|
||||
<Highlight
|
||||
text={resourceTypeToReadableName[props.searchResult.resource]}
|
||||
keywords={[props.searchResult.nameMatch]}
|
||||
/>
|
||||
</strong>
|
||||
<Highlight
|
||||
text={resourceTypeToReadableName[props.searchResult.resource]}
|
||||
keywords={[props.searchResult.nameMatch]}
|
||||
/>
|
||||
</Text>
|
||||
</IconAndContent>
|
||||
);
|
||||
@@ -624,78 +621,71 @@ function ResourceTypeFilterItem(
|
||||
|
||||
export function ServerItem(props: SearchResultItem<SearchResultServer>) {
|
||||
const { searchResult } = props;
|
||||
const server = searchResult.resource;
|
||||
const hasUuidMatches = searchResult.resourceMatches.some(
|
||||
match => match.field === 'name'
|
||||
);
|
||||
const mainField = 'hostname';
|
||||
const visibleMatches = getVisibleMatches(searchResult, mainField);
|
||||
const hasUuidMatches = visibleMatches.hasMatchOnField('name');
|
||||
const hasAddrMatches = visibleMatches.hasMatchOnField('addr');
|
||||
|
||||
return (
|
||||
<IconAndContent
|
||||
<ResourceItem
|
||||
Icon={icons.Server}
|
||||
iconColor="brand"
|
||||
iconOpacity={getRequestableResourceIconOpacity(props.searchResult)}
|
||||
>
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
gap={1}
|
||||
>
|
||||
<Text typography="body2">
|
||||
{props.searchResult.requiresRequest
|
||||
? 'Request access to server '
|
||||
: 'Connect over SSH to '}
|
||||
<strong>
|
||||
<HighlightField field="hostname" searchResult={searchResult} />
|
||||
</strong>
|
||||
</Text>
|
||||
<Box ml="auto">
|
||||
<Text typography="body4">
|
||||
{props.getOptionalClusterName(server.uri)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
title={<HighlightField field={mainField} searchResult={searchResult} />}
|
||||
action={
|
||||
searchResult.requiresRequest
|
||||
? 'Request access to server'
|
||||
: 'Connect over SSH'
|
||||
}
|
||||
clusterName={props.getOptionalClusterName(searchResult.resource.uri)}
|
||||
searchResult={props.searchResult}
|
||||
details={
|
||||
<Labels labels={visibleMatches.labels}>
|
||||
{(hasAddrMatches || hasUuidMatches) && (
|
||||
<ResourceFields>
|
||||
{hasAddrMatches && (
|
||||
<span>
|
||||
<HighlightField field="addr" searchResult={searchResult} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Labels searchResult={searchResult}>
|
||||
<ResourceFields>
|
||||
{server.tunnel ? (
|
||||
<span title="This node is connected to the cluster through a reverse tunnel">
|
||||
↵ tunnel
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<HighlightField field="addr" searchResult={searchResult} />
|
||||
</span>
|
||||
{hasUuidMatches && (
|
||||
<span>
|
||||
UUID:{' '}
|
||||
<HighlightField field={'name'} searchResult={searchResult} />
|
||||
</span>
|
||||
)}
|
||||
</ResourceFields>
|
||||
)}
|
||||
|
||||
{hasUuidMatches && (
|
||||
<span>
|
||||
UUID:{' '}
|
||||
<HighlightField field={'name'} searchResult={searchResult} />
|
||||
</span>
|
||||
)}
|
||||
</ResourceFields>
|
||||
</Labels>
|
||||
</IconAndContent>
|
||||
</Labels>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseItem(props: SearchResultItem<SearchResultDatabase>) {
|
||||
const { searchResult } = props;
|
||||
const db = searchResult.resource;
|
||||
const mainField = 'name';
|
||||
const visibleMatches = getVisibleMatches(searchResult, mainField);
|
||||
const hasDescMatches = visibleMatches.hasMatchOnField('desc');
|
||||
const hasTypeMatches = visibleMatches.hasMatchOnField('type');
|
||||
const hasProtocolMatches = visibleMatches.hasMatchOnField('protocol');
|
||||
|
||||
const $resourceFields = (
|
||||
const $resourceFields = (hasTypeMatches ||
|
||||
hasProtocolMatches ||
|
||||
hasDescMatches) && (
|
||||
<ResourceFields>
|
||||
<span
|
||||
css={`
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
<HighlightField field="type" searchResult={searchResult} />
|
||||
/
|
||||
<HighlightField field="protocol" searchResult={searchResult} />
|
||||
</span>
|
||||
{db.desc && (
|
||||
{(hasTypeMatches || hasProtocolMatches) && (
|
||||
<span
|
||||
css={`
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
<HighlightField field="type" searchResult={searchResult} />
|
||||
/
|
||||
<HighlightField field="protocol" searchResult={searchResult} />
|
||||
</span>
|
||||
)}
|
||||
{hasDescMatches && (
|
||||
<span
|
||||
css={`
|
||||
overflow: hidden;
|
||||
@@ -710,61 +700,47 @@ export function DatabaseItem(props: SearchResultItem<SearchResultDatabase>) {
|
||||
);
|
||||
|
||||
return (
|
||||
<IconAndContent
|
||||
<ResourceItem
|
||||
Icon={icons.Database}
|
||||
iconColor="brand"
|
||||
iconOpacity={getRequestableResourceIconOpacity(props.searchResult)}
|
||||
>
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
gap={1}
|
||||
>
|
||||
<Text typography="body2">
|
||||
{props.searchResult.requiresRequest
|
||||
? 'Request access to db '
|
||||
: 'Set up a db connection to '}
|
||||
<strong>
|
||||
<HighlightField field="name" searchResult={searchResult} />
|
||||
</strong>
|
||||
</Text>
|
||||
<Box ml="auto">
|
||||
<Text typography="body4">{props.getOptionalClusterName(db.uri)}</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* If the description is long, put the resource fields on a separate line.
|
||||
title={<HighlightField field={mainField} searchResult={searchResult} />}
|
||||
searchResult={props.searchResult}
|
||||
action={
|
||||
searchResult.requiresRequest
|
||||
? 'Request access to db'
|
||||
: 'Set up a db connection'
|
||||
}
|
||||
clusterName={props.getOptionalClusterName(searchResult.resource.uri)}
|
||||
details={
|
||||
<>
|
||||
{/* If the description is long, put the resource fields on a separate line.
|
||||
Otherwise show the resource fields and the labels together in a single line.
|
||||
*/}
|
||||
{db.desc.length >= 30 ? (
|
||||
<>
|
||||
{$resourceFields}
|
||||
<Labels searchResult={searchResult} />
|
||||
{db.desc.length >= 30 ? (
|
||||
<>
|
||||
{$resourceFields}
|
||||
<Labels labels={visibleMatches.labels} />
|
||||
</>
|
||||
) : (
|
||||
<Labels labels={visibleMatches.labels}>{$resourceFields}</Labels>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Labels searchResult={searchResult}>{$resourceFields}</Labels>
|
||||
)}
|
||||
</IconAndContent>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppItem(props: SearchResultItem<SearchResultApp>) {
|
||||
const { searchResult } = props;
|
||||
const app = searchResult.resource;
|
||||
const mainField = app.friendlyName ? 'friendlyName' : 'name';
|
||||
const visibleMatches = getVisibleMatches(searchResult, mainField);
|
||||
const hasAddrWithProtocolMatches =
|
||||
visibleMatches.hasMatchOnField('addrWithProtocol');
|
||||
const hasDescMatches = visibleMatches.hasMatchOnField('desc');
|
||||
|
||||
const $appName = (
|
||||
<strong>
|
||||
<HighlightField
|
||||
field={app.friendlyName ? 'friendlyName' : 'name'}
|
||||
searchResult={searchResult}
|
||||
/>
|
||||
</strong>
|
||||
);
|
||||
|
||||
const $resourceFields = (app.addrWithProtocol || app.desc) && (
|
||||
const $resourceFields = (hasAddrWithProtocolMatches || hasDescMatches) && (
|
||||
<ResourceFields>
|
||||
{app.addrWithProtocol && (
|
||||
{hasAddrWithProtocolMatches && (
|
||||
<span
|
||||
css={`
|
||||
flex-shrink: 0;
|
||||
@@ -776,7 +752,7 @@ export function AppItem(props: SearchResultItem<SearchResultApp>) {
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{app.desc && (
|
||||
{hasDescMatches && (
|
||||
<span
|
||||
css={`
|
||||
overflow: hidden;
|
||||
@@ -791,44 +767,32 @@ export function AppItem(props: SearchResultItem<SearchResultApp>) {
|
||||
);
|
||||
|
||||
return (
|
||||
<IconAndContent
|
||||
<ResourceItem
|
||||
Icon={icons.Application}
|
||||
iconColor="brand"
|
||||
iconOpacity={getRequestableResourceIconOpacity(props.searchResult)}
|
||||
>
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
gap={1}
|
||||
>
|
||||
<Text typography="body2">
|
||||
{getAppItemCopy(
|
||||
$appName,
|
||||
app,
|
||||
searchResult.requiresRequest,
|
||||
props.isVnetSupported
|
||||
)}
|
||||
</Text>
|
||||
<Box ml="auto">
|
||||
<Text typography="body4">
|
||||
{props.getOptionalClusterName(app.uri)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* If the description is long, put the resource fields on a separate line.
|
||||
title={<HighlightField field={mainField} searchResult={searchResult} />}
|
||||
searchResult={props.searchResult}
|
||||
action={getAppItemCopy(
|
||||
app,
|
||||
searchResult.requiresRequest,
|
||||
props.isVnetSupported
|
||||
)}
|
||||
clusterName={props.getOptionalClusterName(searchResult.resource.uri)}
|
||||
details={
|
||||
<>
|
||||
{/* If the description is long, put the resource fields on a separate line.
|
||||
Otherwise, show the resource fields and the labels together in a single line.
|
||||
*/}
|
||||
{app.desc.length >= 30 ? (
|
||||
<>
|
||||
{$resourceFields}
|
||||
<Labels searchResult={searchResult} />
|
||||
{app.desc.length >= 30 ? (
|
||||
<>
|
||||
{$resourceFields}
|
||||
<Labels labels={visibleMatches.labels} />
|
||||
</>
|
||||
) : (
|
||||
<Labels labels={visibleMatches.labels}>{$resourceFields}</Labels>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Labels searchResult={searchResult}>{$resourceFields}</Labels>
|
||||
)}
|
||||
</IconAndContent>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -836,9 +800,13 @@ export function WindowsDesktopItem(
|
||||
props: SearchResultItem<SearchResultWindowsDesktop>
|
||||
) {
|
||||
const { searchResult } = props;
|
||||
const windowsDesktop = searchResult.resource;
|
||||
const mainField = 'name';
|
||||
const visibleMatches = getVisibleMatches(searchResult, mainField);
|
||||
const hasAddrWithoutDefaultPortMatches = visibleMatches.hasMatchOnField(
|
||||
'addrWithoutDefaultPort'
|
||||
);
|
||||
|
||||
const $resourceFields = (
|
||||
const $resourceFields = hasAddrWithoutDefaultPortMatches && (
|
||||
<ResourceFields>
|
||||
<span
|
||||
css={`
|
||||
@@ -854,94 +822,118 @@ export function WindowsDesktopItem(
|
||||
);
|
||||
|
||||
return (
|
||||
<IconAndContent
|
||||
<ResourceItem
|
||||
Icon={icons.Desktop}
|
||||
iconColor="brand"
|
||||
iconOpacity={getRequestableResourceIconOpacity(props.searchResult)}
|
||||
>
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
gap={1}
|
||||
>
|
||||
<Text typography="body2">
|
||||
{props.searchResult.requiresRequest
|
||||
? 'Request access to desktop '
|
||||
: 'Connect to desktop '}
|
||||
<strong>
|
||||
<HighlightField field="name" searchResult={searchResult} />
|
||||
</strong>
|
||||
</Text>
|
||||
<Box ml="auto">
|
||||
<Text typography="body4">
|
||||
{props.getOptionalClusterName(windowsDesktop.uri)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Labels searchResult={searchResult}>{$resourceFields}</Labels>
|
||||
</IconAndContent>
|
||||
title={<HighlightField field={mainField} searchResult={searchResult} />}
|
||||
searchResult={props.searchResult}
|
||||
action={
|
||||
searchResult.requiresRequest
|
||||
? 'Request access to desktop'
|
||||
: 'Connect to desktop'
|
||||
}
|
||||
clusterName={props.getOptionalClusterName(searchResult.resource.uri)}
|
||||
details={
|
||||
<Labels labels={visibleMatches.labels}>{$resourceFields}</Labels>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getAppItemCopy(
|
||||
$appName: React.JSX.Element,
|
||||
app: App,
|
||||
requiresRequest: boolean,
|
||||
isVnetSupported: boolean
|
||||
) {
|
||||
): string {
|
||||
if (requiresRequest) {
|
||||
return <>Request access to app {$appName}</>;
|
||||
return 'Request access to the app';
|
||||
}
|
||||
if (app.samlApp) {
|
||||
return <>Log in to {$appName} in the browser</>;
|
||||
return 'Log in via the browser';
|
||||
}
|
||||
if (isWebApp(app) || app.awsConsole) {
|
||||
return <>Launch {$appName} in the browser</>;
|
||||
return 'Launch in the browser';
|
||||
}
|
||||
|
||||
// TCP app
|
||||
if (isVnetSupported) {
|
||||
return <>Connect with VNet to {$appName}</>;
|
||||
return 'Connect with VNet';
|
||||
}
|
||||
return <>Set up an app connection to {$appName}</>;
|
||||
return 'Set up an app connection';
|
||||
}
|
||||
|
||||
export function KubeItem(props: SearchResultItem<SearchResultKube>) {
|
||||
const { searchResult } = props;
|
||||
const mainField = 'name';
|
||||
const visibleMatches = getVisibleMatches(searchResult, mainField);
|
||||
|
||||
return (
|
||||
<IconAndContent
|
||||
<ResourceItem
|
||||
Icon={icons.Kubernetes}
|
||||
iconColor="brand"
|
||||
iconOpacity={getRequestableResourceIconOpacity(props.searchResult)}
|
||||
>
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
gap={1}
|
||||
>
|
||||
<Text typography="body2">
|
||||
{props.searchResult.requiresRequest
|
||||
? 'Request access to Kubernetes cluster '
|
||||
: 'Log in to Kubernetes cluster '}
|
||||
<strong>
|
||||
<HighlightField field="name" searchResult={searchResult} />
|
||||
</strong>
|
||||
</Text>
|
||||
<Box ml="auto">
|
||||
<Text typography="body4">
|
||||
{props.getOptionalClusterName(searchResult.resource.uri)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Labels searchResult={searchResult} />
|
||||
</IconAndContent>
|
||||
title={<HighlightField field={mainField} searchResult={searchResult} />}
|
||||
searchResult={props.searchResult}
|
||||
action={
|
||||
searchResult.requiresRequest
|
||||
? 'Request access to Kubernetes cluster'
|
||||
: 'Log in to Kubernetes cluster'
|
||||
}
|
||||
clusterName={props.getOptionalClusterName(searchResult.resource.uri)}
|
||||
details={<Labels labels={visibleMatches.labels} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes secondary matches to display in the item details.
|
||||
* It omits resource and label matches for terms already matched in the main field,
|
||||
* reducing duplicate highlights.
|
||||
*/
|
||||
export function getVisibleMatches(
|
||||
searchResult: ResourceSearchResult,
|
||||
mainField: ResourceMatch<ResourceSearchResult['kind']>['field']
|
||||
) {
|
||||
const mainFieldMatchedTerms = new Set(
|
||||
searchResult.resourceMatches
|
||||
.filter(match => match.field === mainField)
|
||||
.map(match => match.searchTerm)
|
||||
);
|
||||
|
||||
const secondaryResourceMatchFields = new Set<
|
||||
ResourceMatch<ResourceSearchResult['kind']>['field']
|
||||
>();
|
||||
for (const match of searchResult.resourceMatches) {
|
||||
if (!mainFieldMatchedTerms.has(match.searchTerm)) {
|
||||
secondaryResourceMatchFields.add(match.field);
|
||||
}
|
||||
}
|
||||
|
||||
const labelMatches = searchResult.labelMatches.filter(
|
||||
match => !mainFieldMatchedTerms.has(match.searchTerm)
|
||||
);
|
||||
const labelScores = new Map<string, number>();
|
||||
for (const match of labelMatches) {
|
||||
const currentScore = labelScores.get(match.labelName) || 0;
|
||||
labelScores.set(match.labelName, currentScore + match.score);
|
||||
}
|
||||
|
||||
const labels = searchResult.resource.labels
|
||||
.filter(label => labelScores.has(label.name))
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
// Highest score first.
|
||||
(labelScores.get(b.name) || 0) - (labelScores.get(a.name) || 0)
|
||||
);
|
||||
|
||||
return {
|
||||
labels: {
|
||||
list: labels,
|
||||
matches: labelMatches,
|
||||
},
|
||||
hasMatchOnField: (
|
||||
field: ResourceMatch<ResourceSearchResult['kind']>['field']
|
||||
) => secondaryResourceMatchFields.has(field),
|
||||
};
|
||||
}
|
||||
|
||||
export function NoResultsItem(props: {
|
||||
clustersWithExpiredCerts: Set<uri.ClusterUri>;
|
||||
getClusterName: (resourceUri: uri.ClusterOrResourceUri) => string;
|
||||
@@ -1072,32 +1064,25 @@ export function ResourceSearchErrorsItem(props: {
|
||||
|
||||
function Labels(
|
||||
props: React.PropsWithChildren<{
|
||||
searchResult: ResourceSearchResult;
|
||||
labels: {
|
||||
list: LabelProto[];
|
||||
matches: LabelMatch[];
|
||||
};
|
||||
}>
|
||||
) {
|
||||
const { searchResult } = props;
|
||||
const { labels } = props;
|
||||
|
||||
// Label name to score.
|
||||
const scoreMap: Map<string, number> = new Map();
|
||||
searchResult.labelMatches.forEach(match => {
|
||||
const currentScore = scoreMap.get(match.labelName) || 0;
|
||||
scoreMap.set(match.labelName, currentScore + match.score);
|
||||
});
|
||||
|
||||
const sortedLabelsList = [...searchResult.resource.labels];
|
||||
sortedLabelsList.sort(
|
||||
(a, b) =>
|
||||
// Highest score first.
|
||||
(scoreMap.get(b.name) || 0) - (scoreMap.get(a.name) || 0)
|
||||
);
|
||||
if (!(props.children || labels.list.length)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelsFlex>
|
||||
{props.children}
|
||||
{sortedLabelsList.map(label => (
|
||||
{labels.list.map(label => (
|
||||
<Label
|
||||
key={label.name + label.value}
|
||||
searchResult={searchResult}
|
||||
labelMatches={labels.matches}
|
||||
label={label}
|
||||
/>
|
||||
))}
|
||||
@@ -1123,14 +1108,9 @@ const ResourceFields = styled(Flex).attrs({ gap: 1 })`
|
||||
font-size: ${props => props.theme.fontSizes[0]}px;
|
||||
`;
|
||||
|
||||
function Label(props: {
|
||||
searchResult: ResourceSearchResult;
|
||||
label: tsh.Label;
|
||||
}) {
|
||||
const { searchResult: item, label } = props;
|
||||
const labelMatches = item.labelMatches.filter(
|
||||
match => match.labelName == label.name
|
||||
);
|
||||
function Label(props: { labelMatches: LabelMatch[]; label: LabelProto }) {
|
||||
let { label, labelMatches } = props;
|
||||
labelMatches = labelMatches.filter(match => match.labelName == label.name);
|
||||
const nameMatches = labelMatches
|
||||
.filter(match => match.kind === 'label-name')
|
||||
.map(match => match.searchTerm);
|
||||
@@ -1233,6 +1213,41 @@ function ContentAndAdvancedSearch(
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceItem(props: {
|
||||
Icon: React.ComponentType<IconProps>;
|
||||
searchResult: { requiresRequest: boolean };
|
||||
title: ReactNode;
|
||||
action: ReactNode;
|
||||
clusterName: string;
|
||||
details?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<IconAndContent
|
||||
Icon={props.Icon}
|
||||
iconColor="brand"
|
||||
iconOpacity={getRequestableResourceIconOpacity(props.searchResult)}
|
||||
>
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
gap={1}
|
||||
>
|
||||
<Text typography="body2">{props.title}</Text>
|
||||
<Text typography="body2" color="text.muted">
|
||||
{props.action}
|
||||
</Text>
|
||||
<Box ml="auto">
|
||||
<Text typography="body4" color="text.muted">
|
||||
{props.clusterName}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
{props.details}
|
||||
</IconAndContent>
|
||||
);
|
||||
}
|
||||
|
||||
function getRequestableResourceIconOpacity(args: { requiresRequest: boolean }) {
|
||||
// Unified resources use 0.5 opacity for the requestable resources.
|
||||
return args.requiresRequest ? 0.5 : 1;
|
||||
|
||||
@@ -123,8 +123,36 @@ const SearchResultItems = () => {
|
||||
const searchResults: SearchResult[] = [
|
||||
makeResourceResult({
|
||||
kind: 'server',
|
||||
resourceMatches: [{ field: 'addr', searchTerm: '10.0.0.175' }],
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'service',
|
||||
searchTerm: 'service',
|
||||
score: 40,
|
||||
},
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'service',
|
||||
searchTerm: 'ansible',
|
||||
score: 60,
|
||||
},
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'kernel',
|
||||
searchTerm: 'kernel',
|
||||
score: 45,
|
||||
},
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'arch',
|
||||
searchTerm: 'aarch64',
|
||||
score: 55,
|
||||
},
|
||||
],
|
||||
resource: makeServer({
|
||||
hostname: 'long-label-list',
|
||||
addr: '10.0.0.175:3022',
|
||||
uri: `${clusterUri}/servers/2f96e498-88ec-442f-a25b-569fa915041c`,
|
||||
name: '2f96e498-88ec-442f-a25b-569fa915041c',
|
||||
labels: makeLabelsList({
|
||||
@@ -138,6 +166,14 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'server',
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'service',
|
||||
searchTerm: 'ansible',
|
||||
score: 100,
|
||||
},
|
||||
],
|
||||
resource: makeServer({
|
||||
hostname: 'short-label-list',
|
||||
addr: '',
|
||||
@@ -171,6 +207,7 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'server',
|
||||
resourceMatches: [{ field: 'hostname', searchTerm: 'super' }],
|
||||
resource: makeServer({
|
||||
hostname:
|
||||
'super-long-server-name-with-uuid-2f96e498-88ec-442f-a25b-569fa915041c',
|
||||
@@ -186,6 +223,32 @@ const SearchResultItems = () => {
|
||||
makeResourceResult({
|
||||
kind: 'server',
|
||||
requiresRequest: true,
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'service',
|
||||
searchTerm: 'service',
|
||||
score: 40,
|
||||
},
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'service',
|
||||
searchTerm: 'ansible',
|
||||
score: 60,
|
||||
},
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'kernel',
|
||||
searchTerm: 'kernel',
|
||||
score: 45,
|
||||
},
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'arch',
|
||||
searchTerm: 'aarch64',
|
||||
score: 55,
|
||||
},
|
||||
],
|
||||
resource: makeServer({
|
||||
hostname: 'long-label-list',
|
||||
uri: `${clusterUri}/servers/2f96e498-88ec-442f-a25b-569fa915041c`,
|
||||
@@ -201,6 +264,14 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'app',
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'env',
|
||||
searchTerm: 'dev',
|
||||
score: 100,
|
||||
},
|
||||
],
|
||||
resource: makeAppWithAddr({
|
||||
uri: `${clusterUri}/apps/web-app`,
|
||||
name: 'web-app',
|
||||
@@ -217,6 +288,15 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'app',
|
||||
resourceMatches: [{ field: 'desc', searchTerm: 'SAML' }],
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'aws/Owner',
|
||||
searchTerm: 'Owner',
|
||||
score: 35,
|
||||
},
|
||||
],
|
||||
resource: makeAppWithAddr({
|
||||
uri: `${clusterUri}/apps/saml-app`,
|
||||
name: 'saml-app',
|
||||
@@ -234,6 +314,7 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'app',
|
||||
resourceMatches: [{ field: 'addrWithProtocol', searchTerm: 'local' }],
|
||||
resource: makeAppWithAddr({
|
||||
uri: `${clusterUri}/apps/no-desc`,
|
||||
name: 'no-desc',
|
||||
@@ -249,6 +330,7 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'app',
|
||||
resourceMatches: [{ field: 'desc', searchTerm: 'Lorem' }],
|
||||
resource: makeAppWithAddr({
|
||||
uri: `${clusterUri}/apps/short-desc`,
|
||||
name: 'short-desc',
|
||||
@@ -264,6 +346,15 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'app',
|
||||
resourceMatches: [{ field: 'desc', searchTerm: 'dignissim' }],
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'aws/Environment',
|
||||
searchTerm: 'demo',
|
||||
score: 50,
|
||||
},
|
||||
],
|
||||
resource: makeAppWithAddr({
|
||||
uri: `${clusterUri}/apps/long-desc`,
|
||||
name: 'long-desc',
|
||||
@@ -279,6 +370,7 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'app',
|
||||
resourceMatches: [{ field: 'desc', searchTerm: 'Duis' }],
|
||||
resource: makeAppWithAddr({
|
||||
uri: `${clusterUri}/apps/super-long-desc`,
|
||||
name: 'super-long-desc',
|
||||
@@ -294,6 +386,15 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'app',
|
||||
resourceMatches: [{ field: 'desc', searchTerm: 's' }],
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'access',
|
||||
searchTerm: 's',
|
||||
score: 40,
|
||||
},
|
||||
],
|
||||
resource: makeAppWithAddr({
|
||||
name: 'super-long-app-with-uuid-1f96e498-88ec-442f-a25b-569fa915041c',
|
||||
desc: 'short-desc',
|
||||
@@ -311,6 +412,14 @@ const SearchResultItems = () => {
|
||||
makeResourceResult({
|
||||
kind: 'app',
|
||||
requiresRequest: true,
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'access',
|
||||
searchTerm: 's',
|
||||
score: 40,
|
||||
},
|
||||
],
|
||||
resource: makeAppWithAddr({
|
||||
uri: `${clusterUri}/apps/web-app`,
|
||||
name: 'web-app',
|
||||
@@ -327,6 +436,18 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'database',
|
||||
resourceMatches: [
|
||||
{ field: 'type', searchTerm: 'self' },
|
||||
{ field: 'protocol', searchTerm: 'postgres' },
|
||||
],
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'env',
|
||||
searchTerm: 'dev',
|
||||
score: 100,
|
||||
},
|
||||
],
|
||||
resource: makeDatabase({
|
||||
uri: `${clusterUri}/dbs/no-desc`,
|
||||
name: 'no-desc',
|
||||
@@ -345,6 +466,15 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'database',
|
||||
resourceMatches: [{ field: 'desc', searchTerm: 'm' }],
|
||||
labelMatches: [
|
||||
{
|
||||
labelName: 'aws/Environment',
|
||||
score: 40,
|
||||
kind: 'label-name',
|
||||
searchTerm: 'm',
|
||||
},
|
||||
],
|
||||
resource: makeDatabase({
|
||||
uri: `${clusterUri}/dbs/short-desc`,
|
||||
name: 'short-desc',
|
||||
@@ -363,6 +493,7 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'database',
|
||||
resourceMatches: [{ field: 'desc', searchTerm: 'dignissim' }],
|
||||
resource: makeDatabase({
|
||||
uri: `${clusterUri}/dbs/long-desc`,
|
||||
name: 'long-desc',
|
||||
@@ -381,6 +512,7 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'database',
|
||||
resourceMatches: [{ field: 'desc', searchTerm: 'Duis' }],
|
||||
resource: makeDatabase({
|
||||
uri: `${clusterUri}/dbs/super-long-desc`,
|
||||
name: 'super-long-desc',
|
||||
@@ -435,6 +567,14 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'kube',
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'kube',
|
||||
searchTerm: 'kubersson',
|
||||
score: 100,
|
||||
},
|
||||
],
|
||||
resource: makeKube({
|
||||
name: 'short-label-list',
|
||||
labels: makeLabelsList({
|
||||
@@ -446,6 +586,26 @@ const SearchResultItems = () => {
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'kube',
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'aws/Environment',
|
||||
searchTerm: 'Environment',
|
||||
score: 60,
|
||||
},
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'aws/Owner',
|
||||
searchTerm: 'Owner',
|
||||
score: 45,
|
||||
},
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'kube',
|
||||
searchTerm: 'kubersson',
|
||||
score: 70,
|
||||
},
|
||||
],
|
||||
resource: makeKube({
|
||||
name: 'long-label-list',
|
||||
uri: `${clusterUri}/kubes/long-label-list`,
|
||||
@@ -473,6 +633,14 @@ const SearchResultItems = () => {
|
||||
makeResourceResult({
|
||||
kind: 'kube',
|
||||
requiresRequest: true,
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'im-just-a-smol',
|
||||
searchTerm: 'kube',
|
||||
score: 40,
|
||||
},
|
||||
],
|
||||
resource: makeKube({
|
||||
name: 'short-label-list',
|
||||
labels: makeLabelsList({
|
||||
@@ -509,10 +677,41 @@ const SearchResultItems = () => {
|
||||
with: 'little-to-no-labels',
|
||||
}),
|
||||
}),
|
||||
resourceMatches: [
|
||||
{ field: 'addrWithoutDefaultPort', searchTerm: '192.169.100.50' },
|
||||
],
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'windowsDesktops',
|
||||
searchTerm: 'custom',
|
||||
score: 100,
|
||||
},
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'aws/Environment',
|
||||
searchTerm: 'Environment',
|
||||
score: 60,
|
||||
},
|
||||
{
|
||||
kind: 'label-name',
|
||||
labelName: 'aws/Owner',
|
||||
searchTerm: 'Owner',
|
||||
score: 45,
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeResourceResult({
|
||||
kind: 'windows_desktop',
|
||||
requiresRequest: true,
|
||||
labelMatches: [
|
||||
{
|
||||
kind: 'label-value',
|
||||
labelName: 'im-just-a-smol',
|
||||
searchTerm: 'win',
|
||||
score: 40,
|
||||
},
|
||||
],
|
||||
resource: makeWindowsDesktopWithoutDefaultPort({
|
||||
uri: `${clusterUri}/windows_desktops/short-label-list`,
|
||||
name: 'short-label-list',
|
||||
|
||||
Reference in New Issue
Block a user