Icu 11748 add free search for targets (#1995)
* refactor: 💡 fix how we access socket_address from status call ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * feat: 🎸 add free search capability to targets list ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * test: 💍 update test asserts to look for hds appl state title ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * refactor: 💡 remove filtering for yes, no, & narrow-results ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * refactor: 💡 add aria-label to dropdown search input ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * refactor: 💡 add to handleInput signature comment ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * refactor: 💡 move buildQuery to client daemon handler ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * refactor: 💡 Add new parser for MQL expressions ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * refactor: 💡 use queryParams for searching ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * fix: 🐛 Fix some MQL expressions not being properly filtered ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * style: 💄 update handleSearchInput comment signature ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11748 * Update addons/api/addon/utils/mqlQuery.js Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com> --------- Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com> Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
This commit is contained in:
1 parent
4bf1a88764
commit
221ab1f7bd
14 files changed
+419
-288
No files matched your search
@@ -1,11 +1,16 @@
|
||||
import { pluralize } from 'ember-inflector';
|
||||
import { generateMQLExpression } from '../utils/mqlQuery';
|
||||
|
||||
/**
|
||||
* Not all types are yet supported by the client daemon so we'll
|
||||
* just whitelist the ones we need to start with.
|
||||
* @type {string[]}
|
||||
* just whitelist the ones we need to start with. Keys are the resource
|
||||
* types and the values are the fields that are searchable by the client.
|
||||
* @type {{session: string[], target: string[]}}
|
||||
*/
|
||||
const supportedTypes = ['target', 'session'];
|
||||
const supportedTypes = {
|
||||
target: ['id', 'name', 'description', 'address', 'scope_id'],
|
||||
session: ['id', 'type', 'status', 'endpoint', 'scope_id', 'target_id'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler to sit in front of the API request layer
|
||||
@@ -18,20 +23,36 @@ const ClientDaemonHandler = {
|
||||
const { store, data } = context.request;
|
||||
const { type, query } = data;
|
||||
const isClientDaemonRunning = await this.ipc.invoke(
|
||||
'isClientDaemonRunning'
|
||||
'isClientDaemonRunning',
|
||||
);
|
||||
|
||||
if (!supportedTypes.includes(type) || !isClientDaemonRunning) {
|
||||
if (
|
||||
!Object.keys(supportedTypes).includes(type) ||
|
||||
!isClientDaemonRunning
|
||||
) {
|
||||
return next(context.request);
|
||||
}
|
||||
|
||||
// TODO: Remove usages of recursive from callers and scope_id since
|
||||
// all calls to daemon are recursive, scope_id can be part of search query instead
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let { recursive, scope_id, ...modifiedQuery } = query;
|
||||
let { recursive, scope_id, ...remainingQuery } = query;
|
||||
let searchQuery = '';
|
||||
if (remainingQuery.query) {
|
||||
const { search, filters } = remainingQuery.query;
|
||||
|
||||
searchQuery = generateMQLExpression({
|
||||
search: {
|
||||
text: search,
|
||||
fields: supportedTypes[type],
|
||||
},
|
||||
filters,
|
||||
});
|
||||
}
|
||||
const auth_token_id = this.session.data?.authenticated?.id;
|
||||
modifiedQuery = {
|
||||
...modifiedQuery,
|
||||
remainingQuery = {
|
||||
...remainingQuery,
|
||||
query: searchQuery,
|
||||
auth_token_id,
|
||||
resource: pluralize(type),
|
||||
};
|
||||
@@ -40,7 +61,7 @@ const ClientDaemonHandler = {
|
||||
// e.g. { targets: [...] } or { sessions: [..] }
|
||||
// So this just unwraps to the array, or undefined
|
||||
const [results] = Object.values(
|
||||
await this.ipc.invoke('searchClientDaemon', modifiedQuery)
|
||||
await this.ipc.invoke('searchClientDaemon', remainingQuery),
|
||||
);
|
||||
const payload = { items: results ?? [] };
|
||||
|
||||
@@ -51,7 +72,7 @@ const ClientDaemonHandler = {
|
||||
schema,
|
||||
payload,
|
||||
null,
|
||||
'query'
|
||||
'query',
|
||||
);
|
||||
|
||||
// TODO: Add some pagination
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Takes a POJO representing a filter query and converts it to an
|
||||
* MQL expression string which is understood by the client daemon.
|
||||
*
|
||||
* @example
|
||||
* const filter = generateMQLFilterExpression({
|
||||
* name: { contains: 'search text' },
|
||||
* scope: [{ equals: 'scope1'}, { equals: 'scope2' }],
|
||||
* });
|
||||
*
|
||||
* // (name % "search text") and (scope = "scope1" or scope = "scope2")
|
||||
*/
|
||||
export function generateMQLFilterExpression(filterObj) {
|
||||
if (!filterObj) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expressions = Object.entries(filterObj)
|
||||
.map(([key, filterObjValue]) => {
|
||||
const filterObjValueArray = Array.isArray(filterObjValue)
|
||||
? filterObjValue
|
||||
: [filterObjValue];
|
||||
|
||||
const orClauses = or(
|
||||
filterObjValueArray.map((filterObjValue) => {
|
||||
if (!filterObjValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [operation, value] = Object.entries(filterObjValue)[0];
|
||||
|
||||
switch (operation) {
|
||||
case 'contains':
|
||||
return contains(key, value);
|
||||
case 'gt':
|
||||
return gt(key, value);
|
||||
case 'gte':
|
||||
return gte(key, value);
|
||||
case 'lt':
|
||||
return lt(key, value);
|
||||
case 'lte':
|
||||
return lte(key, value);
|
||||
default:
|
||||
return equals(key, value);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return parenthetical(orClauses);
|
||||
})
|
||||
.filter((item) => item);
|
||||
return and(expressions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a POJO representing a search query and converts it to an
|
||||
* MQL expression string which is understood by the client daemon.
|
||||
*
|
||||
* @example
|
||||
* const search = generateMQLSearchExpression({
|
||||
* text: 'search',
|
||||
* fields: ['id', 'name', 'description']
|
||||
* });
|
||||
*
|
||||
* // (id % 'search' or name % 'search' or description % 'search')
|
||||
*/
|
||||
export function generateMQLSearchExpression(searchObj) {
|
||||
if (!searchObj) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { text, fields } = searchObj;
|
||||
|
||||
return parenthetical(
|
||||
or(fields.map((field) => contains(field, text)).filter((item) => item)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the MQL expression when a user passes in both search and filters.
|
||||
*/
|
||||
export function generateMQLExpression(obj) {
|
||||
const { search, filters } = obj;
|
||||
|
||||
const expressions = [
|
||||
generateMQLSearchExpression(search),
|
||||
generateMQLFilterExpression(filters),
|
||||
].filter((item) => item);
|
||||
|
||||
return and(expressions);
|
||||
}
|
||||
|
||||
const isEmpty = (input) =>
|
||||
input === null || input === undefined || input === '';
|
||||
|
||||
// Escape any double quotes or backslashes
|
||||
const sanitize = (input) => input?.replace(/(["\\])/g, '\\$1');
|
||||
|
||||
// Comparison Operators
|
||||
const equals = (key, value) =>
|
||||
!isEmpty(value) ? `${key} = "${sanitize(value)}"` : null;
|
||||
|
||||
const contains = (key, value) =>
|
||||
!isEmpty(value) ? `${key} % "${sanitize(value)}"` : null;
|
||||
|
||||
const gt = (key, value) =>
|
||||
!isEmpty(value) ? `${key} > "${sanitize(value)}"` : null;
|
||||
|
||||
const gte = (key, value) =>
|
||||
!isEmpty(value) ? `${key} >= "${sanitize(value)}"` : null;
|
||||
|
||||
const lt = (key, value) =>
|
||||
!isEmpty(value) ? `${key} < "${sanitize(value)}"` : null;
|
||||
|
||||
const lte = (key, value) =>
|
||||
!isEmpty(value) ? `${key} <= "${sanitize(value)}"` : null;
|
||||
|
||||
// Logical Operators
|
||||
const and = (clauses) => clauses.join(' and ');
|
||||
|
||||
const or = (clauses) => clauses.join(' or ');
|
||||
|
||||
const parenthetical = (expression) => (expression ? `(${expression})` : null);
|
||||
@@ -52,3 +52,7 @@ toggle-color-theme: Toggle Color Theme
|
||||
toggle-feature-edition: Toggle Feature Edition
|
||||
toggle-licensed-features: Toggle Licensed Features
|
||||
more: ', +{remainingItems} more'
|
||||
search: Search
|
||||
yes: Yes
|
||||
no: No
|
||||
narrow-results: Narrow results
|
||||
@@ -564,6 +564,9 @@ target:
|
||||
connect-more-info:
|
||||
title: Connect for more info
|
||||
description: Additional information for this target will be provided here after your session is established.
|
||||
no-search-results:
|
||||
title: No results found
|
||||
description: Sorry, we weren't able to find any targets for "{query}".
|
||||
titles:
|
||||
new: New Target
|
||||
actions:
|
||||
|
||||
@@ -7,6 +7,7 @@ import Controller from '@ember/controller';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { loading } from 'ember-loading';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class ScopesScopeProjectsTargetsIndexController extends Controller {
|
||||
// =services
|
||||
@@ -17,8 +18,31 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle
|
||||
@service session;
|
||||
@service store;
|
||||
|
||||
// =attributes
|
||||
|
||||
queryParams = ['search'];
|
||||
|
||||
@tracked search;
|
||||
@tracked searchItems = ['Project 1', 'Project 2', 'Project 3'];
|
||||
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Returns true if model is empty but we have a search term
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get noResults() {
|
||||
return this.model.length === 0 && this.search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if model is empty and we have no search term
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get noTargets() {
|
||||
return this.model.length === 0 && !this.search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick connect method used to call main connect method and handle
|
||||
* connection errors unique to this route
|
||||
@@ -103,4 +127,17 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle
|
||||
session_id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles input on each keystroke and the search queryParam
|
||||
* @param {object} event
|
||||
*/
|
||||
@action
|
||||
handleSearchInput(event) {
|
||||
const { value } = event.target;
|
||||
this.search = value;
|
||||
this.router.replaceWith({
|
||||
queryParams: { ...this.queryParams, search: this.search },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,96 +4,5 @@
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { resourceFilter } from 'core/decorators/resource-filter';
|
||||
|
||||
export default class ScopesScopeProjectsTargetsRoute extends Route {
|
||||
// =services
|
||||
|
||||
@service can;
|
||||
@service clusterUrl;
|
||||
@service resourceFilterStore;
|
||||
@service router;
|
||||
@service session;
|
||||
@service store;
|
||||
|
||||
// =attributes
|
||||
|
||||
@resourceFilter({
|
||||
allowed: (route) => route.modelFor('scopes.scope.projects'),
|
||||
serialize: ({ id }) => id,
|
||||
findBySerialized: ({ id }, value) => id === value,
|
||||
})
|
||||
project;
|
||||
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* If arriving here unauthenticated, redirect to index for further processing.
|
||||
*/
|
||||
beforeModel() {
|
||||
if (!this.session.isAuthenticated) this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all targets under current scope.
|
||||
*
|
||||
* NOTE: previously, targets were filtered with API filter queries.
|
||||
* In an effort to offload processing from the controller, targets
|
||||
* are now filtered on the client by projects and authorized_actions.
|
||||
*
|
||||
* @return {Promise{[TargetModel]}}
|
||||
*/
|
||||
async model() {
|
||||
const { id: scope_id } = this.modelFor('scopes.scope');
|
||||
const queryOptions = { scope_id, recursive: true };
|
||||
|
||||
// Recursively query all targets within the current scope
|
||||
let targets = await this.store.query('target', queryOptions);
|
||||
|
||||
// Filter out targets to which users do not have the connect ability
|
||||
targets = targets.filter((t) => this.can.can('connect target', t));
|
||||
|
||||
// If project filters are selected, filter targets by the selected projects
|
||||
if (this.project?.length) {
|
||||
const projectIDs = this.project.map((p) => p?.id);
|
||||
targets = targets.filter((t) => projectIDs.includes(t?.scopeID));
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
// =actions
|
||||
|
||||
@action
|
||||
acknowledge(session) {
|
||||
session.acknowledged = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the specified resource filter field to the specified value.
|
||||
* @param {string} field
|
||||
* @param value
|
||||
*/
|
||||
@action
|
||||
filterBy(field, value) {
|
||||
this[field] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears and filter selections.
|
||||
*/
|
||||
@action
|
||||
clearAllFilters() {
|
||||
this.project = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshes target data.
|
||||
*/
|
||||
@action
|
||||
async refreshTargets() {
|
||||
return this.refresh();
|
||||
}
|
||||
}
|
||||
export default class ScopesScopeProjectsTargetsRoute extends Route {}
|
||||
@@ -4,5 +4,52 @@
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class ScopesScopeProjectsTargetsIndexRoute extends Route {}
|
||||
export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
|
||||
// =services
|
||||
|
||||
@service can;
|
||||
@service clusterUrl;
|
||||
@service resourceFilterStore;
|
||||
@service router;
|
||||
@service session;
|
||||
@service store;
|
||||
|
||||
// =attributes
|
||||
|
||||
queryParams = {
|
||||
search: {
|
||||
refreshModel: true,
|
||||
},
|
||||
};
|
||||
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* If arriving here unauthenticated, redirect to index for further processing.
|
||||
*/
|
||||
beforeModel() {
|
||||
if (!this.session.isAuthenticated) this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all targets under current scope.
|
||||
*
|
||||
* @return {Promise{[TargetModel]}}
|
||||
*/
|
||||
async model({ search = '' }) {
|
||||
// TODO: Filter targets by scope we're in manually
|
||||
// const { id: scope_id } = this.modelFor('scopes.scope');
|
||||
const queryOptions = { query: { search } };
|
||||
|
||||
let targets = await this.store.query('target', queryOptions);
|
||||
|
||||
// Filter out targets to which users do not have the connect ability
|
||||
targets = targets.filter((target) =>
|
||||
this.can.can('connect target', target),
|
||||
);
|
||||
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
@@ -684,3 +684,18 @@ $chevron-down: url("data:image/svg+xml;utf-8,<svg viewBox='0 0 24 24' fill='%231
|
||||
color: var(--token-color-foreground-faint);
|
||||
}
|
||||
}
|
||||
|
||||
// Search & Filtering
|
||||
.targets {
|
||||
.hds-application-state {
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
.target-search-filtering {
|
||||
padding-bottom: 1rem;
|
||||
|
||||
.hds-dropdown-toggle-button__text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,189 +13,152 @@
|
||||
<p>{{t 'resources.target.description'}}</p>
|
||||
</page.header>
|
||||
|
||||
<page.body>
|
||||
{{#if
|
||||
(or
|
||||
@model
|
||||
(has-resource-filter-selections
|
||||
'scopes.scope.projects.targets' 'project'
|
||||
)
|
||||
)
|
||||
}}
|
||||
<Rose::Toolbar as |toolbar|>
|
||||
<toolbar.primary>
|
||||
<Rose::Form as |form|>
|
||||
{{#let
|
||||
(resource-filter 'scopes.scope.projects.targets' 'project')
|
||||
as |filter|
|
||||
}}
|
||||
<Rose::Dropdown
|
||||
@text={{t (concat 'resources.' filter.name '.title')}}
|
||||
@count={{filter.selectedValue.length}}
|
||||
@ignoreClickInside={{true}}
|
||||
as |dropdown|
|
||||
>
|
||||
<ul>
|
||||
{{#let
|
||||
(group-by filter.allowedValues 'scopeModel')
|
||||
as |groups|
|
||||
}}
|
||||
{{#each groups as |groupedItems|}}
|
||||
<li>
|
||||
<dropdown.item>
|
||||
<Rose::Icon
|
||||
@name='flight-icons/svg/org-16'
|
||||
@size='medium'
|
||||
/>
|
||||
{{groupedItems.key.displayName}}
|
||||
</dropdown.item>
|
||||
<ul class='indent-label-2'>
|
||||
<form.checkboxGroup
|
||||
@name={{filter.name}}
|
||||
@items={{groupedItems.items}}
|
||||
@selectedItems={{filter.selectedValue}}
|
||||
@onChange={{route-action 'filterBy' filter.name}}
|
||||
as |group|
|
||||
>
|
||||
<li>
|
||||
<dropdown.item>
|
||||
<group.checkbox
|
||||
@label={{group.item.displayName}}
|
||||
value={{group.item}}
|
||||
/>
|
||||
</dropdown.item>
|
||||
</li>
|
||||
</form.checkboxGroup>
|
||||
</ul>
|
||||
</li>
|
||||
{{/each}}
|
||||
{{/let}}
|
||||
</ul>
|
||||
</Rose::Dropdown>
|
||||
{{/let}}
|
||||
|
||||
{{#if
|
||||
(has-resource-filter-selections
|
||||
'scopes.scope.projects.targets' 'project'
|
||||
)
|
||||
}}
|
||||
<Rose::Button
|
||||
@style='inline-link-action'
|
||||
@iconLeft='flight-icons/svg/x-16'
|
||||
{{on 'click' (route-action 'clearAllFilters')}}
|
||||
>{{t 'actions.clear-all-filters'}}
|
||||
</Rose::Button>
|
||||
{{/if}}
|
||||
</Rose::Form>
|
||||
</toolbar.primary>
|
||||
|
||||
<toolbar.secondary>
|
||||
<ToolbarRefresher @onClick={{route-action 'refreshTargets'}} />
|
||||
</toolbar.secondary>
|
||||
|
||||
</Rose::Toolbar>
|
||||
{{/if}}
|
||||
|
||||
{{#if @model}}
|
||||
|
||||
<Hds::Table
|
||||
@model={{@model}}
|
||||
@columns={{array
|
||||
(hash label=(t 'form.name.label'))
|
||||
(hash label=(t 'form.id.label'))
|
||||
(hash label=(t 'form.type.label'))
|
||||
(hash label=(t 'resources.project.title'))
|
||||
(hash label='')
|
||||
}}
|
||||
@valign='middle'
|
||||
>
|
||||
<:body as |B|>
|
||||
<B.Tr>
|
||||
<B.Td>
|
||||
<span class='hds-font-weight-semibold'>
|
||||
{{#if (can 'read model' B.data)}}
|
||||
<LinkTo
|
||||
data-test-visit-target={{B.data.id}}
|
||||
@route='scopes.scope.projects.targets.target'
|
||||
@model={{B.data.id}}
|
||||
@query={{hash isConnecting=false}}
|
||||
>
|
||||
<page.body class='targets'>
|
||||
{{#if (or @model this.search)}}
|
||||
<div class='target-search-filtering'>
|
||||
<Hds::SegmentedGroup as |S|>
|
||||
<S.TextInput
|
||||
@type='search'
|
||||
placeholder={{t 'actions.search'}}
|
||||
aria-label={{t 'actions.search'}}
|
||||
{{on 'input' this.handleSearchInput}}
|
||||
/>
|
||||
<S.Dropdown as |DD|>
|
||||
<DD.ToggleButton
|
||||
@color='secondary'
|
||||
@text={{t 'resources.scope.title'}}
|
||||
/>
|
||||
<DD.Header @hasDivider={{true}}>
|
||||
<Hds::Form::TextInput::Base
|
||||
@type='search'
|
||||
placeholder={{t 'actions.narrow-results'}}
|
||||
aria-label={{t 'actions.narrow-results'}}
|
||||
data-test-search
|
||||
/>
|
||||
</DD.Header>
|
||||
{{#each this.searchItems as |item|}}
|
||||
<DD.Checkbox @value={{item}} data-test-checkbox={{item}}>
|
||||
{{item}}
|
||||
</DD.Checkbox>
|
||||
{{/each}}
|
||||
</S.Dropdown>
|
||||
<S.Dropdown as |DD|>
|
||||
<DD.ToggleButton @color='secondary' @text='Active session' />
|
||||
<DD.Checkbox>{{t 'actions.yes'}}</DD.Checkbox>
|
||||
<DD.Checkbox>{{t 'actions.no'}}</DD.Checkbox>
|
||||
</S.Dropdown>
|
||||
<S.Dropdown as |DD|>
|
||||
<DD.ToggleButton @color='secondary' @text='Type' @count='2' />
|
||||
<DD.Checkbox>{{t 'resources.target.types.tcp'}}</DD.Checkbox>
|
||||
<DD.Checkbox>{{t 'resources.target.types.ssh'}}</DD.Checkbox>
|
||||
</S.Dropdown>
|
||||
</Hds::SegmentedGroup>
|
||||
</div>
|
||||
{{#if @model}}
|
||||
<Hds::Table
|
||||
@model={{@model}}
|
||||
@columns={{array
|
||||
(hash label=(t 'form.name.label'))
|
||||
(hash label=(t 'form.id.label'))
|
||||
(hash label=(t 'form.type.label'))
|
||||
(hash label=(t 'resources.project.title'))
|
||||
(hash label='')
|
||||
}}
|
||||
@valign='middle'
|
||||
>
|
||||
<:body as |B|>
|
||||
<B.Tr>
|
||||
<B.Td>
|
||||
<span class='hds-font-weight-semibold'>
|
||||
{{#if (can 'read model' B.data)}}
|
||||
<LinkTo
|
||||
data-test-visit-target={{B.data.id}}
|
||||
@route='scopes.scope.projects.targets.target'
|
||||
@model={{B.data.id}}
|
||||
@query={{hash isConnecting=false}}
|
||||
>
|
||||
{{B.data.displayName}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
{{B.data.displayName}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
{{B.data.displayName}}
|
||||
{{/if}}
|
||||
{{#if B.data.description}}
|
||||
<p>{{B.data.description}}</p>
|
||||
{{/if}}
|
||||
</span>
|
||||
</B.Td>
|
||||
<B.Td>
|
||||
<Copyable
|
||||
@text={{B.data.id}}
|
||||
@buttonText={{t 'actions.copy-to-clipboard'}}
|
||||
@acknowledgeText={{t 'states.copied'}}
|
||||
>
|
||||
<code>{{B.data.id}}</code>
|
||||
</Copyable>
|
||||
</B.Td>
|
||||
<B.Td>
|
||||
{{#if B.data.type}}
|
||||
<Hds::Badge
|
||||
@text={{t (concat 'resources.target.types.' B.data.type)}}
|
||||
/>
|
||||
{{/if}}
|
||||
</B.Td>
|
||||
<B.Td>
|
||||
<div>
|
||||
{{/if}}
|
||||
{{#if B.data.description}}
|
||||
<p>{{B.data.description}}</p>
|
||||
{{/if}}
|
||||
</span>
|
||||
</B.Td>
|
||||
<B.Td>
|
||||
<Copyable
|
||||
@text={{B.data.project.id}}
|
||||
@text={{B.data.id}}
|
||||
@buttonText={{t 'actions.copy-to-clipboard'}}
|
||||
@acknowledgeText={{t 'states.copied'}}
|
||||
>
|
||||
<code>{{B.data.project.id}}</code>
|
||||
<code>{{B.data.id}}</code>
|
||||
</Copyable>
|
||||
</div>
|
||||
<Hds::Badge @color='neutral' @text={{B.data.project.name}} />
|
||||
</B.Td>
|
||||
<B.Td>
|
||||
{{#if (can 'connect target' B.data)}}
|
||||
{{#if (can 'read target' B.data)}}
|
||||
<Hds::Button
|
||||
data-test-targets-connect-button={{B.data.id}}
|
||||
@text={{t 'resources.session.actions.connect'}}
|
||||
@color='secondary'
|
||||
@route='scopes.scope.projects.targets.target'
|
||||
@model={{B.data.id}}
|
||||
@query={{hash isConnecting=true}}
|
||||
/>
|
||||
{{else}}
|
||||
<Hds::Button
|
||||
data-test-targets-connect-button={{B.data.id}}
|
||||
@text={{t 'resources.session.actions.connect'}}
|
||||
@color='secondary'
|
||||
{{on 'click' (fn this.quickConnect B.data)}}
|
||||
</B.Td>
|
||||
<B.Td>
|
||||
{{#if B.data.type}}
|
||||
<Hds::Badge
|
||||
@text={{t (concat 'resources.target.types.' B.data.type)}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</B.Td>
|
||||
</B.Tr>
|
||||
</:body>
|
||||
</Hds::Table>
|
||||
|
||||
{{else}}
|
||||
|
||||
<Rose::Layout::Centered>
|
||||
<Rose::Message
|
||||
@title={{t 'resources.target.messages.none.title'}}
|
||||
as |message|
|
||||
>
|
||||
<message.description>
|
||||
{{t 'resources.target.messages.none.description'}}
|
||||
</message.description>
|
||||
</Rose::Message>
|
||||
</Rose::Layout::Centered>
|
||||
|
||||
</B.Td>
|
||||
<B.Td>
|
||||
<div>
|
||||
<Copyable
|
||||
@text={{B.data.project.id}}
|
||||
@buttonText={{t 'actions.copy-to-clipboard'}}
|
||||
@acknowledgeText={{t 'states.copied'}}
|
||||
>
|
||||
<code>{{B.data.project.id}}</code>
|
||||
</Copyable>
|
||||
</div>
|
||||
<Hds::Badge @color='neutral' @text={{B.data.project.name}} />
|
||||
</B.Td>
|
||||
<B.Td>
|
||||
{{#if (can 'connect target' B.data)}}
|
||||
{{#if (can 'read target' B.data)}}
|
||||
<Hds::Button
|
||||
data-test-targets-connect-button={{B.data.id}}
|
||||
@text={{t 'resources.session.actions.connect'}}
|
||||
@color='secondary'
|
||||
@route='scopes.scope.projects.targets.target'
|
||||
@model={{B.data.id}}
|
||||
@query={{hash isConnecting=true}}
|
||||
/>
|
||||
{{else}}
|
||||
<Hds::Button
|
||||
data-test-targets-connect-button={{B.data.id}}
|
||||
@text={{t 'resources.session.actions.connect'}}
|
||||
@color='secondary'
|
||||
{{on 'click' (fn this.quickConnect B.data)}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</B.Td>
|
||||
</B.Tr>
|
||||
</:body>
|
||||
</Hds::Table>
|
||||
{{/if}}
|
||||
{{#if this.noResults}}
|
||||
<Hds::ApplicationState data-test-no-target-results as |A|>
|
||||
<A.Header
|
||||
@title={{t 'resources.target.messages.no-search-results.title'}}
|
||||
/>
|
||||
<A.Body
|
||||
@text={{t
|
||||
'resources.target.messages.no-search-results.description'
|
||||
query=this.search
|
||||
}}
|
||||
/>
|
||||
</Hds::ApplicationState>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if this.noTargets}}
|
||||
<Hds::ApplicationState data-test-no-targets as |A|>
|
||||
<A.Header @title={{t 'resources.target.messages.none.title'}} />
|
||||
<A.Body @text={{t 'resources.target.messages.none.description'}} />
|
||||
</Hds::ApplicationState>
|
||||
{{/if}}
|
||||
</page.body>
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ handle('checkCommand', async (command) => which(command, { nothrow: true }));
|
||||
* Adds the user's token to the client daemon.
|
||||
*/
|
||||
handle('addTokenToClientDaemon', async (data) =>
|
||||
clientDaemonManager.addToken(data)
|
||||
clientDaemonManager.addToken(data),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -139,7 +139,7 @@ handle('checkOS', () => ({
|
||||
* Call the client daemon's search endpoint to retrieve cached results.
|
||||
*/
|
||||
handle('searchClientDaemon', async (request) =>
|
||||
clientDaemonManager.search(request)
|
||||
clientDaemonManager.search(request),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -147,7 +147,7 @@ handle('searchClientDaemon', async (request) =>
|
||||
* socket path as a proxy for whether the daemon is running.
|
||||
*/
|
||||
handle('isClientDaemonRunning', async (request) =>
|
||||
Boolean(clientDaemonManager.socketPath)
|
||||
Boolean(clientDaemonManager.socketPath),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,7 @@ class ClientDaemonManager {
|
||||
const data = spawnSync(daemonStatusCommand);
|
||||
|
||||
const status = jsonify(data);
|
||||
this.#socketPath = status?.socket_address;
|
||||
this.#socketPath = status?.item?.socket_address;
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ module('Acceptance | projects | targets', function (hooks) {
|
||||
|
||||
let getTargetCount;
|
||||
|
||||
const ROSE_APP_STATE_TITLE = '.rose-message-title';
|
||||
const APP_STATE_TITLE = '.hds-application-state__title';
|
||||
const TARGET_DETAILS_ROUTE_NAME =
|
||||
'scopes.scope.projects.targets.target.index';
|
||||
@@ -84,6 +83,8 @@ module('Acceptance | projects | targets', function (hooks) {
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
authenticateSession();
|
||||
// bypass mirage config that expects recursive to be passed in as queryParam
|
||||
this.server.get('/targets', ({ targets }) => targets.all());
|
||||
|
||||
// Generate scopes
|
||||
instances.scopes.global = this.server.create('scope', { id: 'global' });
|
||||
@@ -190,7 +191,7 @@ module('Acceptance | projects | targets', function (hooks) {
|
||||
|
||||
await click(`[href="${urls.targets}"]`);
|
||||
|
||||
assert.dom(ROSE_APP_STATE_TITLE).hasText('No Targets Available');
|
||||
assert.dom(APP_STATE_TITLE).hasText('No Targets Available');
|
||||
});
|
||||
|
||||
test('user cannot navigate to a target without proper authorization', async function (assert) {
|
||||
|
||||
@@ -68,6 +68,8 @@ module('Acceptance | projects | targets | target', function (hooks) {
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
authenticateSession();
|
||||
// bypass mirage config that expects recursive to be passed in as queryParam
|
||||
this.server.get('/targets', ({ targets }) => targets.all());
|
||||
|
||||
// Generate scopes
|
||||
instances.scopes.global = this.server.create('scope', { id: 'global' });
|
||||
|
||||
@@ -22,6 +22,8 @@ module('Acceptance | scopes', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
const APP_STATE_TITLE = '.hds-application-state__title';
|
||||
|
||||
const instances = {
|
||||
scopes: {
|
||||
global: null,
|
||||
@@ -72,6 +74,8 @@ module('Acceptance | scopes', function (hooks) {
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
authenticateSession();
|
||||
// bypass mirage config that expects recursive to be passed in as queryParam
|
||||
this.server.get('/targets', ({ targets }) => targets.all());
|
||||
|
||||
// create scopes
|
||||
instances.scopes.global = this.server.create('scope', { id: 'global' });
|
||||
@@ -228,10 +232,7 @@ module('Acceptance | scopes', function (hooks) {
|
||||
|
||||
await visit(urls.targets);
|
||||
|
||||
assert.ok(
|
||||
find('.rose-message-title').textContent.trim(),
|
||||
'No Targets Available',
|
||||
);
|
||||
assert.ok(find(APP_STATE_TITLE).textContent.trim(), 'No Targets Available');
|
||||
});
|
||||
|
||||
test.skip('connecting to a target', async function (assert) {
|
||||
|
||||
Reference in new issue
Block a user